iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
JavaScript

重新拿回思考力!一起來用 JavaScript 打造 CLI 好玩遊戲區!系列 第 3

Day3 推箱子2:從最爛的方法開始!來寫個有座標的地圖吧!

  • 分享至 

  • xImage
  •  

昨天我們繪製出一個會自己向右走三步的動態畫面了,這時我的腦海中浮現了一個問題:如果我要讓玩家可以按下方向鍵決定要向哪一個方位走一步,以現行的架構來講行得通嗎?

似乎是不太可行,現階段而言就算說它幾乎就像是純手工的動畫也不為過吧?我們必須將這個寫死的圖改成一個正式的地圖系統。

那我們來想像一下,我們期待這是怎樣的一個系統?

制定目標

  1. 具備 x,y 軸坐標系統
  2. 能以 x,y 座標指定角色、箱子、目標點的位置

不急著找出最好的寫法

老實說當我寫出這樣的目標時,我其實是不知道該怎麼做的,腦袋裡也沒有什麼好的想法,但是身為 AI 時代的工程師,我希望我們也不要在想不到做法的時候就被卡住,這樣並不是善用資源的好作法,所以試著去問 AI 這裡該怎麼辦。

不過在 AI 提出的解方裡面,我希望我不要一開始就追求最好的寫法,因為那樣的話我就沒辦法踩過一些爛方法可能會踩到的地雷,也就沒辦法知道究竟為什麼要這樣寫、這樣選擇,而只是一個照抄的機器人而已。

所以我今天在 AI 提出的眾多解法中,選擇的是字串陣列,因為這個方法是我目前看起來最短能達成目標也是我能理解的做法,沒有那麼理想或者錯了也沒關係,之後再來迭代調整。

這也是這個系列最想要練習的目標,我們不追求一次把事情做到位卻沒有去理解為什麼要這麼做,只有親身驗證才能知道問題出在哪裡以及我們為何採取了這樣的策略,才會有所成長。

既然決定要採用字串陣列,我們先把檔案清空,來寫一個如下的字串陣列地圖,並透過 for 迴圈先印出來看看

const map = [
  "########",
  "#      #",
  "#  .   #",
  "#  $   #",
  "#  @   #",
  "#      #",
  "########",
];

for (const row of map){
    console.log(row)
}

以下是印出來的內容,為了便於便是,我加上了 x和y軸的座標給大家參考,y 是陣列中的每一行,x 則是行中的每一個字元,所以我們能看到 $ 位在 x 為3、y為3 的位置


x  01234567
y 0########
  1#      #
  2#  .   #
  3#  $   #
  4#  @   #
  5#      #
  6########

再來解釋一下第一個迴圈

for (let y = 0; y < map.length; y++) {

    console.log('行數',y)
    console.log('這一行的內容',map[y])
}

這個迴圈的目的是計算出總共有幾行,我們可以透過 map[y] 取得這一行的內容,會印出像下面這樣的東西:

行數 0
這一行的內容 ########
行數 1
這一行的內容 #      #
行數 2
這一行的內容 #  .   #
行數 3
這一行的內容 #  $   #
行數 4
這一行的內容 #  @   #
行數 5
這一行的內容 #      #
行數 6
這一行的內容 ########

接著來寫第二個迴圈

for (let y = 0; y < map.length; y++) {

    for ( let x = 0; x < map[y].length; x++){
        console.log(`座標:${x},${y},內容:${map[y][x]}`)
    }
}

第二個迴圈利用 map[y] 取得每一行的內容,並從中解析出每一列的內容

這樣我們就可以得到每一個座標的內容了,這邊要注意一下,座標通常是寫 x,y,但是取得陣列的時候因為是先以陣列中的每一個項目( y )為單位,然後才去取該項目內的字元( x ),所以我們在取得內容的時候會用 map[y][x]

那我們就來整理一下這個雙層迴圈,讓他可以印出完整的地圖吧

const map = [
    "########",
    "#      #",
    "#  .   #",
    "#  $   #",
    "#  @   #",
    "#      #",
    "########",
];
for (let y = 0; y < map.length; y++) {
    //用來儲存每一行的內容
    let row = ''

    for ( let x = 0; x < map[y].length; x++){
        //把每一個字按照順序存進去 row
        row += map[y][x]
    }

    //印出這一行
    console.log(row)
}

把玩家 箱子 目標點找出來

我們現在已經有一個地圖了,接著要來找到遊戲中每一個物件的位置,包含玩家、箱子、目標點,其實就只是加上判斷式

let player = null ; // 用來儲存玩家位置
let box = null ; // 用來儲存箱子位置
let goal = null ; // 用來儲存目標點位置

for (let y = 0; y < map.length; y++) {
    
    let row = ''

    for ( let x = 0; x < map[y].length; x++){
 
        row += map[y][x]

        // 加入這三個判斷式,一旦抓到座標就儲存起來
        if(map[y][x] === '@'){
            player = {x,y}
        }
        if(map[y][x] === '$'){
            box = {x,y}
        }
        if(map[y][x] === '.'){
            goal = {x,y}
        }
    }

    
    console.log(row)
    
}

// 記得在迴圈外再執行
console.log('player',player)
console.log('box',box)
console.log('goal',goal)

讓玩家動起來

現在已經成功地畫出了第一個畫面,並且找到了物件的座標,接著我們要試著讓玩家動起來,我們試著去改變玩家的 x 軸

//上方檔案不動

player.x += 3;
console.log(player)

但是這樣並不會自動產生新的畫面,只能改變座標位置而已,因此我們需要一個可重用的 function 來渲染新的畫面

function render(){
// 這裡請把先前的迴圈貼進來

}

//記得執行這個函式
render()

這樣我們就可以從以下印出來的程式碼觀察到幾個現象是接下來要再去處理的點


########
#      #
#  .   #
#  $   #
#  @   #
#      #
########
player { x: 3, y: 4 }
box { x: 3, y: 3 }
goal { x: 3, y: 2 }
########
#      #
#  .   #
#  $   #
#  @   #
#      #
########
  1. 因為我們目前是使用 console.log 所以之後要改成 process.stdout.write 才可以在同一個位置渲染新的畫面
  2. 我們目前是直接複製先前的程式碼,所以我們要把這段程式碼中的物件給隱藏,並且以新的座標更新物件位置
function render() {
    for (let y = 0; y < map.length; y++) {
        //用來儲存每一行的內容
        let row = ''

        for (let x = 0; x < map[y].length; x++) {

            if ( map[y][x] === '@' || map[y][x] === '$' || map[y][x] === '.') {
                row += ' '
            } else {
                row += map[y][x]
            }

        }

        //印出這一行
        console.log(row)

    }

}

首先把迴圈內的判斷式改成這樣,如果是三個物件的其中一個,就加入一個空格,來隱藏掉原本的物件,如果不是三個中的其中一個,就照原本的匯入就可以

########
#      #
#      #
#      #
#      #
#      #
########

現在第二個畫面已經印出全空的畫面了,但我們還要把角色加上去,我們拿各個物件的座標逐一和每一個座標比對,如果座標正確就印出對應的物件,並把原本的物件清除

            //逐一比對座標,如果比對到了就印出對應的物件
            if(player.x === x && player.y === y){
                row += '@'
            }else if(box.x === x && box.y === y){
                row += '$'
            }else if(goal.x === x && goal.y === y){
                row += '.'    
            //把原本的物件清除
            }else if (map[y][x] === '@' || map[y][x] ==='$' || map[y][x] ==='.') {
                row += ' '
            } else {
                row += map[y][x]
            }

到這邊我們先精簡一下程式碼,讓 render 統一負責每一次的渲染,同時在下方使用一個迴圈讓 player 每往右邊移動一步,就觸發一次重新渲染

//地圖初始化
const map = [
    "########",
    "#      #",
    "#  .   #",
    "#  $   #",
    "#  @   #",
    "#      #",
    "########",
];

let player = null; // 用來儲存玩家位置
let box = null; // 用來儲存箱子位置
let goal = null; // 用來儲存目標點位置

//用於計算三個物件的位置
for (let y = 0; y < map.length; y++) {

    for (let x = 0; x < map[y].length; x++) {

        if (map[y][x] === '@') {
            player = { x, y }
        }
        if (map[y][x] === '$') {
            box = { x, y }
        }
        if (map[y][x] === '.') {
            goal = { x, y }
        }
    }

}

//渲染畫面
function render() {
    for (let y = 0; y < map.length; y++) {
        //用來儲存每一行的內容
        let row = ''

        for (let x = 0; x < map[y].length; x++) {

            if(player.x === x && player.y === y){
                row += '@'
            }else if(box.x === x && box.y === y){
                row += '$'
            }else if(goal.x === x && goal.y === y){
                row += '.'
            }else if (map[y][x] === '@' || map[y][x] ==='$' || map[y][x] ==='.') {
                row += ' '
            } else {
                row += map[y][x]
            }

        }

        //印出這一行
        console.log(row)
    }

}

//初次渲染
render()

// 讓 player 每往右邊移動一格,就重新渲染新的畫面
for ( let i = 0; i < 3 ; i ++){
    player.x += 1
    render()
}

那麼現在已經可以移動了,我們接著要用先前說過的 process.stdout.write 來重繪整個畫面

首先要把現在使用 console.log 一次一行的作法,改成用 /n 拼起來成為一個統一的字串

function render() {

    //儲存整個畫面的字串
    let frame = ''

    for (let y = 0; y < map.length; y++) {
        let row = ''

        for (let x = 0; x < map[y].length; x++) {
        
        // 小優化,把常用的寫法儲存成變數
        const cell = map[y][x]
            
            // 下面這邊把 map[y][x] 都替換成 cell
            if(player.x === x && player.y === y){
                row += '@'
            }else if(box.x === x && box.y === y){
                row += '$'
            }else if(goal.x === x && goal.y === y){
                row += '.'
            }else if (cell === '@' || cell ==='$' || cell ==='.') {
                row += ' '
            } else {
                row += cell
            }

        }

        //把這行儲存進去 frame,並記得結尾換行
        frame += row + '\n'
    }

    // 這邊改成 process.stdout.write
    process.stdout.write(frame)

}

我們發現結果跟使用 console.log 一樣還是印出了很多畫面,原因就跟之前一樣,我們需要先推出空地,然後回到左上角

//地圖初始化
const map = [
    "########",
    "#      #",
    "#  .   #",
    "#  $   #",
    "#  @   #",
    "#      #",
    "########",
];

let player = null; // 用來儲存玩家位置
let box = null; // 用來儲存箱子位置
let goal = null; // 用來儲存目標點位置

//用於計算三個物件的位置
for (let y = 0; y < map.length; y++) {

    for (let x = 0; x < map[y].length; x++) {

        if (map[y][x] === '@') {
            player = { x, y }
        }
        if (map[y][x] === '$') {
            box = { x, y }
        }
        if (map[y][x] === '.') {
            goal = { x, y }
        }
    }

}

//渲染畫面
function render() {

    //儲存整個畫面的字串
    let frame = ''

    for (let y = 0; y < map.length; y++) {
        let row = ''

        for (let x = 0; x < map[y].length; x++) {
        
        // 小優化,把常用的寫法儲存成變數
        const cell = map[y][x]
            
            // 下面這邊把 map[y][x] 都替換成 cell
            if(player.x === x && player.y === y){
                row += '@'
            }else if(box.x === x && box.y === y){
                row += '$'
            }else if(goal.x === x && goal.y === y){
                row += '.'
            }else if (cell === '@' || cell ==='$' || cell ==='.') {
                row += ' '
            } else {
                row += cell
            }

        }

        //把這行儲存進去 frame,並記得結尾換行
        frame += row + '\n'
    }

    // 一樣先印出來看看結果
    process.stdout.write("\x1b[7A");
    process.stdout.write(frame)

}

//初次渲染
process.stdout.write("\n".repeat(map.length));
render()

for ( let i = 0; i < 3 ; i ++){
    player.x += 1
    render()
}

但此時我們發現我們只有看到最後一個畫面,那是因為他執行得很快,以至於我們根本沒機會看到前面的畫面,因此我們需要用 setInterval 來讓每個畫面強制顯示 1 秒

把最後的這個迴圈改成我們之前有操作過的 timer

let count = 2;

const timer = setInterval(() => {
  
  player.x += 1;
  render()
  count --;

  if (count < 1) {
    clearInterval(timer);
  }
}, 1000);

這時就可以成功渲染出畫面啦!

實際跑完一整輪從想法->實作->試錯到完成目標的過程是不是很令人滿足呢,我自己是覺得蠻快樂的!明天的問題是:如何讓使用者可以使用方向鍵操控角色動起來!如果你想要的話,也可以練習自己拆解步驟、選擇一個可能的寫法先來寫寫看唷!

如果你想要看完整的程式碼,可以參考這裡:Day3 程式碼


上一篇
Day2 推箱子1:讓 Terminal 動起來吧!
下一篇
Day4 推箱子3:用自己的語言來描述,讓角色可以被操控吧!
系列文
重新拿回思考力!一起來用 JavaScript 打造 CLI 好玩遊戲區!7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言